TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Flashcards : file de révision (dues + nouvelles), création manuelle, génération IA.2import { NextResponse } from "next/server";3import { z } from "zod";4import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts";5import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";6import { all, get, run } from "@/lib/db/index.ts";7import { normalizeCourse } from "@/lib/learning/helpers.ts";8import { getPrompt } from "@/lib/prompts.ts";9import { completeChat } from "@/lib/openrouter/client.ts";10import { resolvePreset, getModel, estimateCost } from "@/lib/openrouter/registry.ts";11import { hybridSearch, buildContext } from "@/lib/rag/search.ts";12import { checkBudget, logUsage } from "@/lib/usage.ts";1314export async function GET(req: Request, ctx: { params: Promise<{ course: string }> }) {15 try {16 const user = await requireUser();17 const course = normalizeCourse((await ctx.params).course);18 requireEnrollment(user.id, course);19 const url = new URL(req.url);20 const conceptSlug = url.searchParams.get("concept");21 const conceptFilter = conceptSlug22 ? "AND f.concept_id = (SELECT id FROM concepts WHERE course_code = ? AND slug = ?)"23 : "";24 const params: unknown[] = [user.id, course];25 if (conceptSlug) params.push(course, conceptSlug);2627 // Cartes dues (état existant) puis nouvelles (jamais vues), plafonnées.28 const due = all(29 `SELECT f.id, f.type, f.front, f.back, f.concept_id, c.name as concept_name, cs.ef, cs.interval_days, cs.reps, cs.due_at, cs.favorite30 FROM card_states cs JOIN flashcards f ON f.id = cs.card_id LEFT JOIN concepts c ON c.id = f.concept_id31 WHERE cs.user_id = ? AND f.course_code = ? ${conceptFilter} AND cs.suspended = 0 AND cs.due_at <= datetime('now')32 ORDER BY cs.due_at LIMIT 40`,33 ...params34 );35 const fresh = all(36 `SELECT f.id, f.type, f.front, f.back, f.concept_id, c.name as concept_name37 FROM flashcards f LEFT JOIN concepts c ON c.id = f.concept_id38 WHERE f.course_code = ? ${conceptSlug ? "AND f.concept_id = (SELECT id FROM concepts WHERE course_code = ? AND slug = ?)" : ""}39 AND (f.owner_user_id IS NULL OR f.owner_user_id = ?)40 AND f.id NOT IN (SELECT card_id FROM card_states WHERE user_id = ?)41 ORDER BY f.id LIMIT 15`,42 ...(conceptSlug ? [course, course, conceptSlug, user.id, user.id] : [course, user.id, user.id])43 );44 const stats = {45 total: get<{ n: number }>(46 "SELECT COUNT(*) as n FROM flashcards WHERE course_code = ? AND (owner_user_id IS NULL OR owner_user_id = ?)", course, user.id47 )?.n ?? 0,48 learned: get<{ n: number }>(49 `SELECT COUNT(*) as n FROM card_states cs JOIN flashcards f ON f.id = cs.card_id WHERE cs.user_id = ? AND f.course_code = ? AND cs.reps >= 2`,50 user.id, course51 )?.n ?? 0,52 suspended: get<{ n: number }>(53 `SELECT COUNT(*) as n FROM card_states cs JOIN flashcards f ON f.id = cs.card_id WHERE cs.user_id = ? AND f.course_code = ? AND cs.suspended = 1`,54 user.id, course55 )?.n ?? 0,56 };57 return NextResponse.json({ due, fresh, stats });58 } catch (e) {59 return apiError(e);60 }61}6263const createSchema = z.object({64 action: z.literal("create"),65 conceptSlug: z.string().max(120).nullable(),66 type: z.enum(["qa", "definition", "formula", "error", "comparison", "calc"]).default("qa"),67 front: z.string().min(3).max(2000),68 back: z.string().min(1).max(4000),69});70const generateSchema = z.object({71 action: z.literal("generate"),72 conceptSlug: z.string().min(1).max(120),73 count: z.number().int().min(2).max(10).default(5),74});7576export async function POST(req: Request, ctx: { params: Promise<{ course: string }> }) {77 try {78 await assertSameOrigin();79 const user = await requireUser();80 const course = normalizeCourse((await ctx.params).course);81 requireEnrollment(user.id, course);82 const body = await parseBody(req, z.discriminatedUnion("action", [createSchema, generateSchema]));8384 if (body.action === "create") {85 const conceptId = body.conceptSlug86 ? get<{ id: number }>("SELECT id FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug)?.id ?? null87 : null;88 const r = run(89 `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, owner_user_id, validated)90 VALUES (?, ?, ?, ?, ?, 'user', ?, 0)`,91 course, conceptId, body.type, body.front, body.back, user.id92 );93 return NextResponse.json({ ok: true, cardId: Number(r.lastInsertRowid) });94 }9596 // Génération IA fondée sur le matériel du cours (RAG) — cartes privées à l'étudiant.97 const budget = checkBudget(user.id);98 if (!budget.ok) return NextResponse.json({ error: budget.reason }, { status: 429 });99 const concept = get<{ id: number; name: string; description: string }>(100 "SELECT id, name, description FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug101 );102 if (!concept) return NextResponse.json({ error: "Concept inconnu." }, { status: 404 });103104 const results = await hybridSearch({ query: concept.name + " " + concept.description, spaces: [`official-${course.toLowerCase()}`], k: 6 });105 const context = buildContext(results, 8000);106 const model = await resolvePreset("economique");107 const t0 = Date.now();108 const { text, promptTokens, completionTokens } = await completeChat({109 model,110 messages: [111 { role: "system", content: [getPrompt("base-system"), course === "IMM1003" ? getPrompt("course-imm1003") : getPrompt("course-imm1033"), getPrompt("flashcard-generation")].join("\n\n---\n\n") },112 { role: "user", content: `Concept ciblé : « ${concept.name} » (slug: ${body.conceptSlug}). Produis ${body.count} cartes.\n\nMatériel du cours :\n${context.text}` },113 ],114 jsonMode: true,115 temperature: 0.5,116 });117 const mi = await getModel(model);118 logUsage({ userId: user.id, model, kind: "flashcard-gen", tokensIn: promptTokens, tokensOut: completionTokens, cost: mi ? estimateCost(mi, promptTokens, completionTokens) : 0, latencyMs: Date.now() - t0 });119120 let cards: { type?: string; front?: string; back?: string }[] = [];121 try {122 const parsed = JSON.parse(text.replace(/^```json?\s*|\s*```$/g, ""));123 cards = Array.isArray(parsed.flashcards) ? parsed.flashcards : [];124 } catch {125 return NextResponse.json({ error: "La génération a produit un format invalide. Réessayez." }, { status: 502 });126 }127 const created: number[] = [];128 for (const c of cards.slice(0, body.count)) {129 if (!c.front || !c.back) continue;130 const r = run(131 `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, owner_user_id, validated)132 VALUES (?, ?, ?, ?, ?, 'ai', ?, 0)`,133 course, concept.id, ["qa", "definition", "formula", "error", "comparison", "calc"].includes(c.type ?? "") ? c.type : "qa",134 String(c.front).slice(0, 2000), String(c.back).slice(0, 4000), user.id135 );136 created.push(Number(r.lastInsertRowid));137 }138 return NextResponse.json({ ok: true, created: created.length });139 } catch (e) {140 return apiError(e);141 }142}143